feat: add daemon mode with IPC, service installation, and CLI subcomm…#93
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new background “daemon mode” to the Pinggy CLI, allowing tunnels to be started/stopped/queried from a long-running local daemon via a localhost IPC server, with optional OS service installation.
Changes:
- Introduces daemon lifecycle + IPC HTTP server/client (
src/daemon/*) and apinggy daemon/pinggy dCLI subcommand router/handlers. - Adds daemon metadata/log paths under the Pinggy config directory and an early
_daemon-childexecution path inmain. - Adds a cross-platform service installer (systemd user unit / launchd agent / Windows Task Scheduler) and updates CLI help/options accordingly.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/configDir.ts | Adds daemon info/log path helpers and ensures base config dir exists for daemon artifacts. |
| src/main.ts | Parses args earlier, configures logging, and adds an early _daemon-child branch to run the daemon child process. |
| src/daemon/serviceInstaller.ts | Adds platform-specific service installation/uninstallation logic for the daemon. |
| src/daemon/ipcServer.ts | Implements a localhost-only HTTP IPC server exposing ping/list/start/stop/shutdown endpoints. |
| src/daemon/ipcClient.ts | Implements a minimal HTTP client used by the foreground CLI to talk to the daemon. |
| src/daemon/daemonManager.ts | Implements daemon start/stop/status logic via spawning and daemon.json polling. |
| src/daemon/daemonChild.ts | Implements the daemon child entrypoint: starts IPC server, writes daemon.json, autostarts tunnels, and handles shutdown. |
| src/cli/subcommands.ts | Extends subcommand routing to include daemon/d. |
| src/cli/options.ts | Adds hidden internal _daemon-child option to support child-process mode. |
| src/cli/help.ts | Updates help text to document daemon commands (but currently drops pinggy start docs). |
| src/cli/daemonCommands.ts | Adds daemon subcommand verbs: start/stop/status/ps/tunnel-stop/service-install/service-uninstall. |
Comments suppressed due to low confidence (1)
src/cli/subcommands.ts:41
isSubcommand()only checksrawArgs[0], sopinggy --loglevel debug daemon start(or any global flags before the subcommand) won’t route into subcommand mode even thoughparseCliArgs()would correctly identify the first positional. Consider switching subcommand detection to use parsedpositionals[0](or strip leading options) so subcommands work when global flags precede them.
const SUBCOMMANDS = new Set(["config", "start", "daemon", "d"]);
/**
* Check if the raw args start with a known subcommand.
*/
export function isSubcommand(rawArgs: string[]): boolean {
return rawArgs.length > 0 && SUBCOMMANDS.has(rawArgs[0]);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export async function stopDaemon(): Promise<boolean> { | ||
| const info = getDaemonInfo(); | ||
| if (!info) return false; | ||
|
|
||
| try { | ||
| // Try HTTP shutdown first (works on all platforms including Windows) | ||
| const { IPCClient } = await import("./ipcClient.js"); | ||
| const client = new IPCClient(info.port); | ||
| await client.shutdown(); | ||
| return true; |
| export async function startDaemon(): Promise<DaemonInfo> { | ||
| // Check if already running | ||
| const existing = getDaemonInfo(); | ||
| if (existing) { | ||
| return existing; | ||
| } | ||
|
|
||
| const { command, args } = getDaemonSpawnArgs(); | ||
| logger.info("Spawning daemon child", { command, args }); | ||
|
|
||
| const child = spawn(command, args, { | ||
| detached: true, | ||
| stdio: "ignore", | ||
| env: { ...process.env }, | ||
| }); | ||
|
|
||
| child.unref(); | ||
|
|
||
| // Wait for daemon.json to appear (child writes it after IPC server binds) | ||
| const info = await pollForDaemonInfo(DAEMON_SPAWN_TIMEOUT_MS); | ||
| if (!info) { | ||
| throw new Error("Daemon failed to start within timeout. Check daemon.log for details."); | ||
| } | ||
|
|
||
| return info; | ||
| } |
| function installWindows(): void { | ||
| const binaryPath = resolveBinaryPath(); | ||
| const cmd = `schtasks /Create /TN "${WINDOWS_TASK_NAME}" /TR "${binaryPath} -D" /SC ONLOGON /RL LIMITED /F`; | ||
| execSync(cmd, { stdio: "inherit" }); | ||
| console.log(`Scheduled task "${WINDOWS_TASK_NAME}" created (runs at login).`); |
| this.routes.set("POST /tunnels/start", async (body) => { | ||
| const { name } = JSON.parse(body); | ||
| if (!name) return { error: "Missing 'name' field" }; | ||
| // Import at call time to avoid circular deps | ||
| const { findConfig } = await import("../cli/configStore.js"); | ||
| const saved = findConfig(name); | ||
| if (!saved) return { error: `No config found matching "${name}"` }; | ||
|
|
||
| const config = { | ||
| ...saved.tunnelConfig, | ||
| configId: saved.configId, | ||
| name: saved.name, | ||
| } as TunnelConfigV1; | ||
| return await this.ops.handleStartV2(config); | ||
| }); | ||
|
|
||
| this.routes.set("POST /tunnels/stop", async (body) => { | ||
| const { tunnelid } = JSON.parse(body); | ||
| if (!tunnelid) return { error: "Missing 'tunnelid' field" }; | ||
| return await this.ops.handleStop(tunnelid); | ||
| }); |
| /** | ||
| * Daemon child process entry point. | ||
| * Called when the CLI is invoked with --_daemon-child. | ||
| * | ||
| * Responsibilities: | ||
| * 1. Redirect stdout/stderr to daemon.log | ||
| * 2. Start the IPC HTTP server | ||
| * 3. Write daemon.json (port + pid) atomically | ||
| * 4. Start all auto-start tunnels | ||
| * 5. Handle graceful shutdown (cleanup daemon.json) |
| function generateSystemdUnit(binaryPath: string): string { | ||
| return `[Unit] | ||
| Description=Pinggy Tunnel Daemon | ||
| After=network-online.target | ||
| Wants=network-online.target | ||
|
|
||
| [Service] | ||
| Type=forking | ||
| ExecStart=${binaryPath} -D | ||
| Restart=on-failure | ||
| RestartSec=5 | ||
|
|
||
| [Install] | ||
| WantedBy=default.target | ||
| `; |
| function generateLaunchdPlist(binaryPath: string): string { | ||
| // Split binary path for cases like "node /path/to/script" | ||
| const parts = binaryPath.split(" "); | ||
| const programArgs = parts.map((p) => ` <string>${p}</string>`).join("\n"); | ||
|
|
||
| return `<?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" |
…ands Introduce background daemon support via `pinggy daemon` (or `pinggy d`) subcommand with start/stop/status/ps/tunnel-stop/service-install verbs. Includes IPC server/client for tunnel management, system service installer (systemd/launchd/schtasks), and daemon child process handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nd service installer
…ing and help messages and removed unused codes
- daemonManager spawns via ELECTRON_RUN_AS_NODE when hosted by Electron,
resolving the cli-js entry through createRequire so argv[1] no longer
needs to point at us.
- IPCClient/TunnelClient take an origin ("app"|"cli"|"remote") that
rides on X-Pinggy-Origin. Daemon stores it on ManagedTunnel, persists
it in daemon-state.json, and uses it in the log filename as
<origin>__<name>__<tunnelId>.log so app vs cli tunnels are
distinguishable on disk and in /logs/paths responses.
- TunnelClient gains the TunnelOperations-shaped wrappers needed for
the desktop app to use it as a drop-in for the in-process SDK.
- Tunnel-logging toggle: setTunnelLoggingEnabled / isTunnelLoggingEnabled
in tunnelLogger.ts, GET/POST /config/tunnel-logging routes, exposed
via TunnelClient. Disabling detaches live transports immediately.
- getActiveTunnelIds() on TunnelManager so /logs/paths reports
running=true only for tunnels still alive in memory (not stopped or
fatally errored).
- New restartCommand, logCommand, logsCommand subcommands plus help
and options updates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…PCCLIENT DaemonHEalth check
…n lifecycle files
- TunnelManager rejects duplicate-configId starts with TunnelAlreadyRunningError; CLI renders the live tunnel state (URLs, status, ID) with attach/stop hint instead of timing out on a generic spinner. - Add isStopping flag + stopped listener so an external pinggy stop closes the attached TUI cleanly instead of flashing the disconnect/reconnect modal. The SDK's disconnect callback now skips notify when stop is in progress; a stopped WS event is emitted from ipcServer subscriptions. - Thread noWait through StartTunnelConfig (ipcRoutes/ipcClient/ipcServer/handler/TunnelClient) and drive the spinner from WS events in startForegroundViaDaemon (submitting -> created -> waiting for connection -> live). Race-guarded against url_ready firing before subscribe by a one-shot handleListV2 check.
… detached mode while starting a tunnel
…Full config save test added and udp test modified to retry three times before fail #98
| export function trackIPCTunnelStart( | ||
| tunnelId: string, | ||
| origin: TunnelOrigin, | ||
| mode: "foreground" | "detached" = "detached", |
| ): void { | ||
| // Foreground tunnels die with the CLI that started them, so they have no | ||
| // business in the crash-recovery state file. | ||
| if (mode === "foreground") return; |
| /** | ||
| * `pinggy restart <name|id>` — Restart a running tunnel. | ||
| */ | ||
| import { TunnelClient } from "../../../daemon/tunnelClient.js"; |
There was a problem hiding this comment.
should we now use imports relative to the root of the project? in js it is possible to do with @. .. ?
| process.exit(1); | ||
| } | ||
|
|
||
| if (intent.kind === "remote-only") { |
There was a problem hiding this comment.
hardcoded strings like "remote-only" should be types.
In claude .md try to enforce that we prefer types whenever any hardcoded value repeats. ideally in java it would have been enum.
… and improved code quality.
…le tests and hardcoded strings replaced with types
…ands
Introduce background daemon support via
pinggy daemon(orpinggy d) subcommand with start/stop/status/ps/tunnel-stop/service-install verbs. Includes IPC server/client for tunnel management, system service installer (systemd/launchd/schtasks), and daemon child process handling.